Skip to content

3 Plugins development

F-WebShop

Introduction

Plugins are the core of F-Webshop. Every action on the site, frontend or backend, can goes through a plugins. Plugins act as containers for one or more of the following:

  • Business objects - declared as PHP classes, these resources are automatically persisted by F-Webshop based on their configuration
  • Data files - yml files declaring metadata (views or workflows), configuration data (plugins parameterization), demonstration data and more
  • Web controllers - Handle requests from web browsers
  • Static web data - Images, CSS or javascript files used by the web interface or website

A plugin can be made of all of these things, or just one. Using plugins system, developers ensure that native functionality can be upgraded with new releases of the core product, and that it is protected from any changes that a merchant wants to make to the platform. F-Webshop provides very powerful interface for extending its existing features. We can apply hooks on the specific event in application, for example on placing order, on adding item to cart we can create a new feature like managing our office location. Each task can be performed in F-Webshop by creating a plugin. F-Webshop plugins means creating your separate code to extend functionality of core. In this section we will explain the basic plugin structure(directory or Folder and code) and the working model of the F-Webshop plugin.

The MVC Pattern

In the typical MVC pattern, the flow of the application is something like this:

  • There is main entry point - app.php/app_dev.php - from where the entire app routing mechanism is determined.
  • Based on this routing mechanism and requested URL pattern, the app will call the appropriate controller.
  • The controller then calls the appropriate views.
  • Finally, the view files collect the data from models and display the data.

F-Webshop's MVC architecture adds a few layers to the MVC pattern, but the basic flow of control of an application is like this:

  • There is main entry point - symfony frontend controller (app.php or app_dev.php) - from where the whole app will be initialized.
  • Base on the requested URL appropriate controller will be called.
  • Controller defines the pages and load the layout files for those pages. During the building of the controller, Symfony will fetch all the values of the controller’s arguments in the attributes of the request. Symfony has a very nice component ParamConverter, which allows to convert parameters from URL into services (adapters). Each adapter is a service that creates one or more converters(objects) that should be specific to each argument. For every controller’s arguments one converter should be created. Also, if you want to follow recommended F-Webshop flow in your development, each controller’s arguments must be specified as constant in Util class.

  • Layout tells the controllers which twig files to use.

  • Blocks collect the data from models and helpers files and pass it to templates files.
  • Templates files receive data and render html.

Full application data flow is shown below:

Architectural diagram

Structure

Plugin structure diagram can be represented as shown below:

Architectural diagram

Plugin consist of the following components:

  • Twig extensions contain functions that are used to display data in templates. These functions are used for defining common business logic (such as image resize, validation).
  • Managers contain the business logic of plugin.
  • Repositories contains functions that are used for database interaction. A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction. Objects can be added to and removed from the Repository, as they can from a simple collection of objects, and the mapping code encapsulated by the Repository will carry out the appropriate operations behind the scenes. Conceptually, a Repository encapsulates the set of objects persisted in a data store and the operations performed over them, providing a more object-oriented view of the persistence layer. Repository also supports the objective of achieving a clean separation and one-way dependency between the domain and data mapping layers.
  • Controller in the normal MVC(Model View Framework) is the class which control the business logic and presentation. You have to create the controller as PHP file in the Routing directory of your plugin. The controller name format should be like IndexController.php. Here Index is the name of the controller and Controller is prefix. In the normal MVC action is the event which is being handled by the controller. Like clicking a button is the action of the Interest calculator controller. The same thing is in F-webshop. Action of the plugin is the public function of the controller class. Request first land in the action function.
  • Model is the main folder which handle database logic in application. At some place model are in one to one relationship and on other place they are one to many relationship. Models are built with the Object relation Mapper(ORM) developed by doctrine. It dynamically generate the php magic function to load the field of the database. Be careful while using the ORM. Wrong use can cause the database performance slower.
  • Commands facilitates creations and use of command line scripts.
  • Adapters makes mapping of parameters from URL to the instance of the specified class (converter). To make it work, it is also necessary to declare adapter as a service with tag request.param_converter.
  • EventListener/EventSubscriber – The most common way to listen to an event is to use the EventListener class. This class allows you to subscribe to a single event. By configuration, you can decide if the EventListener runs the provided code in the chain or not. You can also manipulate the execution chain by adding a ‘priority’ method in the ‘Tag’ context for your listener definition. Next to the EventListener, there is also the EventSubscriber. This defines one or more methods that listen to one or various events. The main difference with the listeners is that subscribers tell the dispatcher what they’re listening to, while an EventListener listens only to what it’s configured for.

Now Let’s make it practical

A new plugin folder must be created first in project root folder. This is the place where all plugins files will be stored and customized. Each plugin must ending with suffix Plugin.

src/Plugins/YourNewPluginNamePlugin

In this section we will describe how to create VideoPlugin. Product Videos plugin allows merchants to attach videos on product pages. The store admin can upload or embed videos from YouTube and Vimeo. The store admin can also customize the video.

Plugin folder and file structure:

Video plugin

Inside the plugin folder, it is necessary to create a new folder named settings which serves as a place to store main plugine configuration files:

  • plugin.yml
  • settings.yml
settings.yml

This is the core plugin file, which imports all other settings that configure the plugin:

imports:
    - { resource: '../config/services.yml' }
    - { resource: '../config/model.yml' }
    - { resource: '../config/view.yml' }
    - { resource: '../config/assets-config.yml' }
view.yml

This file configures plugin view path. It is recommended to name it related to the plugin as shown in the example below:

parameters:
    empire_product_video_plugin: "%kernel.root_dir%/../src/Plugins/EmpireProductVideoPlugin"

twig:
    paths:
       "%empire_product_video_plugin%/views": productVideoPluginViews
assets-config.yml

This file serves as a list of resources (javascript and stylesheets) which plugin uses. These resources are compiled during the project build and they can be organized in any way the developer wants. The example shows the basic skeleton of assets-config.yml which is necessary for compiling to work.

assetic:
    assets:
     product_video_compiled_css:
        output: 'Resource/Plugins/EmpireProductVideoPlugin/css/product_video_compiled.css'
        filter: uglifycss
        inputs:
          - '../src/Plugins/EmpireProductVideoPlugin/Resource/css/product-video.css'

     add_product_video_compiled_js:
        output: 'Resource/Plugins/EmpireProductVideoPlugin/js/add_product_video_compiled.js'
        filter: uglifyjs2
        inputs:
          - '../src/Plugins/EmpireProductVideoPlugin/Resource/js/product-video-add.js'

     edit_product_video_compiled_js:
         output: 'Resource/Plugins/EmpireProductVideoPlugin/js/edit_product_video_compiled.js'
         filter: uglifyjs2
         inputs:
           - '../src/Plugins/EmpireProductVideoPlugin/Resource/js/product-video-edit.js'

     all_product_videos_compiled:
       output: 'Resource/Plugins/EmpireProductVideoPlugin/js/all_product_videos_compiled.js'
       filter: uglifyjs2
       inputs:
         - '../src/Plugins/EmpireProductVideoPlugin/Resource/js/all-product-video.js'

     youtube_player_compiled_js:
            output: 'Resource/Plugins/EmpireProductVideoPlugin/js/youtube-player.js'
            filter: uglifyjs2
            inputs:
              - '../src/Plugins/EmpireProductVideoPlugin/Resource/js/youtube-video-player.jquery.js'
              - '../src/Plugins/EmpireProductVideoPlugin/Resource/js/jquery.mousewheel.js'
              - '../src/Plugins/EmpireProductVideoPlugin/Resource/js/perfect-scrollbar.js'

     youtube_player_compiled_css:
            output: 'Resource/Plugins/EmpireProductVideoPlugin/css/youtube-player.css'
            filter: uglifycss
            inputs:
              - '../src/Plugins/EmpireProductVideoPlugin/Resource/css/perfect-scrollbar.css'
              - '../src/Plugins/EmpireProductVideoPlugin/Resource/css/youtube-video-player.min.css'
              - '../src/Plugins/EmpireProductVideoPlugin/Resource/css/icons.min.css'

UglifyJS is a JavaScript parser/compressor/beautifier toolkit. It can be used to combine and minify JavaScript assets so that they require less HTTP requests and make your site load faster.

UglifyCSS is a CSS compressor/beautifier that is very similar to UglifyJS.

Assetic Bundle is part of Symfony standard edition package. It is used to join all CSS files or all JavaScript files included in template, so loading time and number of file requests to web server are minimal. For example if you have 5 or more CSS files included and also have few JavaScript files included in your twig template, page loading speed is slower than if you put all those CSS in one file and also all JavaScript in one file. But doing it manually is not good solution. I suppose that you don’t want to manually join different JavaScript or CSS files. Let assetic bundle do that for us. There is one more important benefit of using assetic: with that you can also compress your css and JavaScript files and remove white spaces between code in them, so file size of those files are significantly smaller which give us smaller loading time and increase speed of page loading.

product-video.css

This file contains main css for admin panel.

#product-preview{
    display: none;
}



#product-preview {
    align-items: center;
    justify-content: center;
    border: 1px solid #ddd;
    padding: 15px;
    background-color: #fff;
}

.video-action h4, .video-action p {
    font-size: 15px;
}

.btn-red {
    padding: 10px 20px;
}

input[disabled] {
    cursor: no-drop !important;
}

.video-add #viewMore:empty {
    display: none;
}

.video-add #product-preview .img-holder {
    height: 160px;
    margin-bottom: 15px;
    margin-right: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 10px;
    flex: 1;
}

.video-add #product-preview .video-action {
    flex: 2;
}

.video-add .dz-image {
    display: none !important;
}
.video-add .dz-remove {
    margin-top: 60px;
}

.video-add .dz-details {
    top: 20px !important;
}

.video-edit #viewMore:empty {
    display: none;
}

.video-edit #product-preview .img-holder {
    height: 160px;
    margin-bottom: 15px;
    margin-right: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 10px;
    flex: 1;
}

.video-edit #product-preview .video-action {
    flex: 2;
}

.video-edit .dz-image {
    display: none !important;
}

.video-edit #product-preview {
    display: flex;
}

.video-edit .dz-remove {
    margin-top: 60px;
}

.video-edit .dropzone .dz-details {
    top: 20px !important;
}
youtube_player_compiled_css

The resource youtube_player_compiled_css is used to define style on the product page, and contains several external css files, which can be found on the git repository :

  • perfect-scrollbar.css
  • youtube-video-player.min.css
  • perfect-scrollbar.css
youtube_player_compiled_js

The resource youtube_player_compiled_css contains several external external javascript libraries, which can also be found on the git repository :

  • youtube-video-player.jquery.js
  • jquery.mousewheel.js
  • perfect-scrollbar.js
product-video-add.js

The resource add_product_video_compiled_js contains file product-video-add.js, that contains the logic for processing the form, which is used to add entities and send the data to the server.


var searchResultConteiner = $('#searchResult ul');
var productSearch = $('#productSearch');
var viewMoreLink = $('#viewMore');
var loadingDiv = $(".loading-div");
/** Video title */
var title = $('#productVideoName');
var searchResultDiv = $('#searchResult');
/** Video description  */
var description = $('#videoHTMLDescription');


/** Preview div variables */
var productPreviewDiv = $('#product-preview');
var productPreviewDivImg = $('#preview-img');
var productPreviewDivProductName = $('#preview-product-name');
var productPreviewDivProductPrice = $('#preview-product-price');

/** Youtube regex expression for testing */
var youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;

/** Vimeo regex expression for testing */
var vimeoRegex   = /^(?:https?:\/\/)?(?:www\.)?(vimeo.com\/)((channels\/[A-z]+\/)|(groups\/[A-z]+\/videos\/))?([0-9]+)/;

/** Video input fields */
var videoLinkURL = $('#videoLinkURL');

var uploadForm   = $('#upload-form');
var playlistForm = $('#playlist-form');
var typeSelect   = $('#videoType');
var playlistId   = $('#playlistId');

var videoFile = {};
var product = {};
var videoProduct = {};
product.page = 1;
product.offset = 4;

Dropzone.autoDiscover = false;
initateDescriptionEditor("videoHTMLDescription");

/**
 * DEFAULT IMAGE OBJECT CONSTRUCTOR
 * @param name
 * @param webPath
 * @param position
 * @param base64_content
 * @param id
 * @constructor
 */
function Video(name, webPath, position, base64_content, id){
    this.name = name;
    this.webPath = webPath;
    this.position = position;
    this.base64_content = base64_content;
    if(id != undefined){
        this.id = id;
    }
}

typeSelect.change(function () {
    if (typeSelect.val() === 'upload') {
        uploadForm.show();
        playlistForm.hide();
    } else {
        uploadForm.hide();
        playlistForm.show();
    }
});

function initiateDropzone(primaryVideo){

    console.log("PRIMARY IMAGE", primaryVideo);

    /**
     * PRIMARY DROPZONE FOR MAIN PRODUCT IMAGE UPLOAD
     */

    productVideoDropzone = new Dropzone("#product-video-upload",{
        url: "127.0.0.1", // Set the url
        thumbnailWidth: 80,
        thumbnailHeight: 80,
        parallelUploads: 1,
        maxFiles:1,
        dictRemoveFile: Translator.trans("Remove file"),
        dictDefaultMessage: Translator.trans("Drop files here to upload"),
        acceptedFiles: ".mp4,.avi,.mov,.gif",
        addRemoveLinks:true,
        //previewTemplate: previewTemplate,
        autoProcessQueue: false, // Make sure the files aren't queued until manually added
        //previewsContainer: "form#primaryDropzone", // Define the container to display the previews
        clickable: true, // Define the element that should be used as click trigger to select files.
        init: function() {
            this.hiddenFileInput.removeAttribute('multiple');
        }

    });

    if(primaryVideo != undefined){
        video = primaryVideo;
        /** Create the mock file:*/
        var mockFile = { name: primaryVideo.name, size: 12345 };

        /** Call the default addedfile event handler*/
        productVideoDropzone.emit("addedfile", mockFile);

        /** And optionally show the thumbnail of the file:*/
        productVideoDropzone.emit("thumbnail", mockFile, primaryVideo.web_path);

        /** Make sure that there is no progress bar, etc...*/
        productVideoDropzone.emit("complete", mockFile);
        productVideoDropzone.files.push( mockFile );

    }


    productVideoDropzone.on("addedfile", function (file) {
        /** Capture the Dropzone instance as closure. */
        var _this = this;

        /** Remove the already added file along with it's thumbnail on the add of new file */
        var existingFiles = productVideoDropzone.getFilesWithStatus(Dropzone.QUEUED);
        if(existingFiles.length > 0){
            this.removeFile(existingFiles[0]);
        }


        /** Read file into currentImage */
        var reader = new FileReader();
        reader.onloadend = function () {
            var videoBase64 = reader.result;
            video = new Video(file.name, "", "", videoBase64);
            videoFile = video;
            console.log("CURRENT IMAGE in added file", video);
        };
        reader.readAsDataURL(file);
    });

    productVideoDropzone.on("removedfile", function (file) {
        video = null;

        console.log("CURRENT IMAGE AFTER DELETE", video);
    });
}

initiateDropzone();

var dropzoneInput = $(".dz-hidden-input");

var typingTimer;
var doneTypingInterval = 500;

productSearch.on('keyup', function () {
    clearTimeout(typingTimer);
    typingTimer = setTimeout(searchProducts, doneTypingInterval);
});

productSearch.on('keydown', function () {
    clearTimeout(typingTimer);
});


/**
 * Trigger ajax product search
 */
function searchProducts(){

    /**
     * @param ctx
     * @param data
     * Callback function to handle server response on adapter send data event
     */
    var callbackFunction = function (ctx, data) {
        searchResultConteiner.html(data.html);
        if (!data.html) {
            searchResultDiv.css('visibility', 'hidden');
        } else {
            searchResultDiv.css('visibility', 'visible');
        }
        if(data.hasMoreResults){
            viewMoreLink.html('<button id="viewMoreResults" class="btn btn-blue">' + Translator.trans("View more") + '</button>');
        } else {
            viewMoreLink.html('');
        }
        /** Reset page number when input value hsa changed */
        product.page = 1;
    };

    /** Get search field value */
    product.search = $(productSearch).val();

    /** If search string is not empty send data*/
    if(product.search){
        adapter.sendData(Routing.generate('json_product_video_search', {_locale: getURLParameter('locale')}, true), product, "POST", callbackFunction, $(productSearch));
    }else{

        /** If search string is empty remove view more link and empty search result container */
        viewMoreLink.html('');
        searchResultConteiner.html('');
        searchResultDiv.css('visibility', 'hidden');
    }

}

/**
 * Event on view more clicked, returns next set of results
 */
$(document).on('click', '#viewMoreResults', function (event) {
    /**
     * @param ctx
     * @param data
     * Callback function to handle server response on adapter send data event
     */
    var callbackFunction = function (ctx, data) {
        searchResultConteiner.append(data.html);
        if(!data.hasMoreResults){
            viewMoreLink.html('');
        }
    };

    /** Increment page number */
    product.page ++;

    adapter.sendData(Routing.generate('json_product_video_search', {_locale: getURLParameter('locale')}, true), product, "POST", callbackFunction, $(this));

});

/**
 * On save button clicked collect data and send request
 */
$('#save-product-video-btn').on('click', function(){

    if(isValid()){
        $(this).prop('disabled', true);
        loadingDiv.show();
        loadingDiv.css({"display" : "flex"});

        var video = {};
        if (typeSelect.val() === 'upload') {
            /** create video object from input data */
            video = collectVideoObject();
        } else {
            video = {
                playlist_id: playlistId.val(),
                product: videoProduct,
                enabled: $('#enabled').prop('checked'),
                type: 'playlist'
            }
        }

        /** Handle response */
        var callbackFunction = function (ctx, data) {
            $(ctx).prop('disabled', false);
            $(".loading-div").hide();
            if(data.code == 200){
                toastr.success(Translator.trans(data.message));
                setTimeout(function () {
                    window.location = Routing.generate('product_video_edit', {_locale: getURLParameter('locale'), id: data.videoId})
                }, 1000);
            } else {
                toastr.error(Translator.trans(data.message));
            }
        };

        /** Send video data for saving */
        adapter.sendData(Routing.generate('json_product_video_add', {_locale: getURLParameter('locale')}, true), video, "POST", callbackFunction, $(this));

    }
});

/**
 *
 * @returns {boolean}
 */
function isValid(){

    if($.isEmptyObject(videoProduct)){
        toastr.error(Translator.trans('Product not selected!'));
        productSearch.focus();
        return false;
    }

    if (typeSelect.val() === 'upload') {
        if(!title.val()){
            toastr.error(Translator.trans('Video title can not be empty'));
            title.focus();
            return false;
        } else if(!description.val()){
            toastr.error(Translator.trans('Video description can not be empty'));
            description.focus();
            return false;
        } else if(!videoExists()){
            toastr.error(Translator.trans('Video is not added'));
            return false;
        }
    } else {
        if (!playlistId.val()) {
            toastr.error(Translator.trans('Please provide playlist id'));
            return false;
        }
    }

    return true;
}

/**
 *
 * @returns {boolean|*}
 */
function videoExists(){

    return vimeoRegex.test(videoLinkURL.val()) || youtubeRegex.test(videoLinkURL.val()) || productVideoDropzone.files.length;
}

/**
 * Collect data and create video object to be sent via adapter
 */
function collectVideoObject(){

    /** Get video title from input */
    var title = $('#productVideoName').val();

    /** Get video description from input */
    var description = $('#videoHTMLDescription').val();


    if (youtubeRegex.test(videoLinkURL.val())) {

        /** Get video youtube url from input */
        var youtubeUrl = videoLinkURL.val();

    } else if (vimeoRegex.test(videoLinkURL.val())) {

        /** Get video vimeo url from input */
        var vimeoUrl = videoLinkURL.val();
    }



    /** Get video enabled status from checkbox */
    var enabled = $('#enabled').prop('checked');


    /** Create video object from data collected */

    var video = {
        title: title,
        description: description,
        enabled: enabled,
        youtube_url: youtubeUrl,
        vimeo_url: vimeoUrl,
        product: videoProduct,
        type: 'upload'
    };

    if( productVideoDropzone.files.length) {
        video.video_document = videoFile;
    }

    return video;
}

/**
 * On product clicked, add product id to productsInVideo array
 */
$(document).on('click', '.add-product-model', function(){

    /** Get holder element of the clicked product to get information from */
    var parentHolderElement = $(this).closest('.productTagList');

    /** Get product id from data-id attribute */
    var id = parentHolderElement.find('.productName').attr('data-id');

    /** Create product object with Id*/
    var product = {
        id: id
    };
    searchResultDiv.css('visibility', 'hidden');
    /** Set product to main videoProduct */
    videoProduct = product;

    /** Show product preview */
    setProduct(parentHolderElement);

    /** Disable product search when product is selected */
    productSearch.prop( "disabled", true );

    /** Reset search and result fields when product is selected */
    resetSerachAndResult();

});



function setProduct(parentHolderElement){

    /** Collect data from holder element name, price and img for preview */
    var productName = parentHolderElement.find('.productName').html();
    var productPrice = parentHolderElement.find('.info').find('span').html();
    var productImage = parentHolderElement.find('.img-holder').find('img').attr('src');

    /** Set collected data to corresponding elements */
    productPreviewDivImg.attr('src', productImage);
    productPreviewDivProductName.html(productName);
    productPreviewDivProductPrice.html(productPrice);

    /** Show preview div */
    productPreviewDiv.show();
    productPreviewDiv.css({"display" : "flex"});
}

/**
 *  Reset video product
 */
$('#remove-product-from-video').on('click', function(){

    /** Set video product to null */
    videoProduct = null;

    /** Hide product preview div */
    productPreviewDiv.hide();

    /** Enable product search input */
    productSearch.prop('disabled', false);

    /** Reset preview product */
    resetPreviewProduct();
});

/**
 *  Reset preview div
 */
function resetPreviewProduct(){

    /** Reset image preview */
    productPreviewDivImg.attr('src', '');
    /** Reset product name preview */
    productPreviewDivProductName.html('');
    /** Reset price preview */
    productPreviewDivProductPrice.html('');
}


/**
 * Reset search field and results
 */
function resetSerachAndResult(){

    /** Empty result container */
    searchResultConteiner.html('');

    /** Remove videw more link */
    viewMoreLink.html('');

    /** Empty product search field */
    productSearch.val('');
}

/** On change check if video can be added */
videoLinkURL.on('change', function(){

    /** Check if dropzone has file uploaded */
    if(productVideoDropzone.files.length){
        /** Reset input value */
        $(this).val('');
        toastr.error(Translator.trans('Only one video allowed, video file uploaded already'));
        //alert('Only one video allowed, video file uploaded already');
    } else {
        /** Save link to variable */
        var videoLinkInput = $(this).val();
        if(videoLinkInput && videoLinkIsValid(videoLinkInput)){
            dropzoneInput.prop("disabled",true);
            $('.dz-input').hide();
        }else{
            dropzoneInput.prop('disabled', false);
            $('.dz-input').show();
            videoLinkURL.val('');
        }
    }

});

/**
 *
 * @param videoLinkInput
 * @returns {boolean}
 */
function videoLinkIsValid(videoLinkInput){

    if(!vimeoRegex.test(videoLinkInput) && !youtubeRegex.test(videoLinkInput)){

        toastr.error(Translator.trans('Video link is not valid. Please copy the valid Youtube or Vimeo link'));
        return false;
    }
    else{

        return true;
    }

}
product-video-edit.js

The resource edit_product_video_compiled_js contains file product-video-edit.js, that contains the logic for processing the form, which is used to edit entities and send the data to the server.

var video = {};

/** Video title */
var title = $('#productVideoName');
var loadingDiv = $(".loading-div");

/** Video description  */
var description = $('#videoHTMLDescription');
var searchResultDiv = $('#searchResult');
var searchResultConteiner = $('#searchResult ul');
var productSearch = $('#productSearch');
var viewMoreLink = $('#viewMore');

/** Preview div variables */
var productPreviewDiv = $('#product-preview');
var productPreviewDivImg = $('#preview-img');
var productPreviewDivProductName = $('#preview-product-name');
var productPreviewDivProductPrice = $('#preview-product-price');

/** Youtube regex expression for testing */
var youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;

/** Vimeo regex expression for testing */
var vimeoRegex   = /^(?:https?:\/\/)?(?:www\.)?(vimeo.com\/)((channels\/[A-z]+\/)|(groups\/[A-z]+\/videos\/))?([0-9]+)/;

/** Video input fields */
var videoLinkURL = $('#videoLinkURL');

var uploadForm   = $('#upload-form');
var playlistForm = $('#playlist-form');
var typeSelect   = $('#videoType');
var playlistId   = $('#playlistId');

var videoFile = {};
var product = {};
var videoProduct = {};
product.page = 1;
product.offset = 4;

Dropzone.autoDiscover = false;
initateDescriptionEditor("videoHTMLDescription");

/**
 * DEFAULT IMAGE OBJECT CONSTRUCTOR
 * @param name
 * @param webPathhttp://www.empire.local/app_dev.php/sr/backend/home
 * @param position
 * @param base64_content
 * @param id
 * @constructor
 */
function Video(name, webPath, position, base64_content, id){
    this.name = name;
    this.webPath = webPath;
    this.position = position;
    this.base64_content = base64_content;
    if(id != undefined){
        this.id = id;
    }
}

function initiateDropzone(primaryVideo){

    console.log("PRIMARY IMAGE", primaryVideo);

    /**
     * PRIMARY DROPZONE FOR MAIN PRODUCT IMAGE UPLOAD
     */

    productVideoDropzone = new Dropzone("#product-video-upload",{
        url: "127.0.0.1", // Set the url
        thumbnailWidth: 80,
        thumbnailHeight: 80,
        parallelUploads: 1,
        maxFiles:1,
        dictRemoveFile: Translator.trans("Remove file"),
        dictDefaultMessage: Translator.trans("Drop files here to upload"),
        acceptedFiles: ".mp4,.avi,.mov,.gif",
        addRemoveLinks:true,
        //previewTemplate: previewTemplate,
        autoProcessQueue: false, // Make sure the files aren't queued until manually added
        //previewsContainer: "form#primaryDropzone", // Define the container to display the previews
        clickable: true, // Define the element that should be used as click trigger to select files.
        init: function() {
            this.hiddenFileInput.removeAttribute('multiple');
        }

    });

    if(primaryVideo != undefined){
        video = primaryVideo;
        /** Create the mock file:*/
        var mockFile = { name: primaryVideo.name, size: 12345 };

        /** Call the default addedfile event handler*/
        productVideoDropzone.emit("addedfile", mockFile);

        /** And optionally show the thumbnail of the file:*/
        //productVideoDropzone.emit("thumbnail", mockFile, primaryVideo.web_path);

        /** Make sure that there is no progress bar, etc...*/
        productVideoDropzone.emit("complete", mockFile);
        productVideoDropzone.files.push( mockFile );

    }


    productVideoDropzone.on("addedfile", function (file) {
        /** Capture the Dropzone instance as closure. */
        var _this = this;

        /** Remove the already added file along with it's thumbnail on the add of new file */
        var existingFiles = productVideoDropzone.files;
        if(existingFiles.length > 1){
            this.removeFile(existingFiles[0]);
        }


        /** Read file into currentImage */
        var reader = new FileReader();
        reader.onloadend = function () {
            var videoBase64 = reader.result;
            video = new Video(file.name, "", "", videoBase64);
            videoFile = video;
            console.log("CURRENT IMAGE in added file", video);
        };
        reader.readAsDataURL(file);
    });

    productVideoDropzone.on("removedfile", function (file) {
        video = null;

        console.log("CURRENT IMAGE AFTER DELETE", video);
    });
}



var dropzoneInput = $(".dz-hidden-input");


// typeSelect.change(function () {
//     if (typeSelect.val() === 'upload') {
//         uploadForm.show();
//         playlistForm.hide();
//     } else {
//         uploadForm.hide();
//         playlistForm.show();
//     }
// });

var typingTimer;
var doneTypingInterval = 500;

productSearch.on('keyup', function () {
    clearTimeout(typingTimer);
    typingTimer = setTimeout(searchProducts, doneTypingInterval);
});

productSearch.on('keydown', function () {
    clearTimeout(typingTimer);
});

/**
 * Trigger ajax product search
 */
function searchProducts(){

    /**
     * @param ctx
     * @param data
     * Callback function to handle server response on adapter send data event
     */
    var callbackFunction = function (ctx, data) {
        searchResultConteiner.html(data.html);
        if (!data.html) {
            searchResultDiv.css('visibility', 'hidden');
        } else {
            searchResultDiv.css('visibility', 'visible');
        }
        if(data.hasMoreResults){
            viewMoreLink.html('<button id="viewMoreResults" class="btn-raised-blue">' + Translator.trans("View more") + '</button>');
        } else {
            viewMoreLink.html('');
        }
        /** Reset page number when input value hsa changed */
        product.page = 1;
    };

    /** Get search field value */
    product.search = $(productSearch).val();

    /** If search string is not empty send data*/
    if(product.search){
        adapter.sendData(Routing.generate('json_product_video_search', {_locale: getURLParameter('locale')}, true), product, "POST", callbackFunction, $(productSearch));
    }else{

        /** If search string is empty remove view more link and empty search result container */
        viewMoreLink.html('');
        searchResultConteiner.html('');
        searchResultDiv.css('visibility', 'hidden');
    }

}

/**
 * Event on view more clicked, returns next set of results
 */
$(document).on('click', '#viewMoreResults', function (event) {
    /**
     * @param ctx
     * @param data
     * Callback function to handle server response on adapter send data event
     */
    var callbackFunction = function (ctx, data) {
        searchResultConteiner.append(data.html);
        if(!data.hasMoreResults){
            viewMoreLink.html('');
        }
    };

    /** Increment page number */
    product.page ++;

    adapter.sendData(Routing.generate('json_product_video_search', {_locale: getURLParameter('locale')}, true), product, "POST", callbackFunction, $(this));

});

/**
 * On save button clicked collect data and send request
 */
$('#edit-product-video-btn').on('click', function(){

    if(isValid()){


        $(this).prop('disabled', true);
        loadingDiv.show();
        loadingDiv.css({"display" : "flex"});

        var video = {};
        if (typeSelect.val() === 'upload') {
            /** create video object from input data */
            video = collectVideoObject();
        } else {
            video = {
                id: id,
                playlist_id: playlistId.val(),
                product: videoProduct,
                enabled: $('#enabled').prop('checked'),
                type: 'playlist'
            }
        }

        /** Handle response */
        var callbackFunction = function (ctx, data) {
            $(ctx).prop('disabled', false);
            $(".loading-div").hide();

            if(data.code == 200){
                toastr.success(Translator.trans(data.message));
                setTimeout(function () {
                    window.location = Routing.generate('product_videos_all', {_locale: getURLParameter('locale')});
                    //$(this).enable();
                }, 1000);
            } else {
                toastr.error(Translator.trans(data.message));
            }
        };
        /** Send video data for saving */
        adapter.sendData(Routing.generate('json_product_video_edit', {_locale: getURLParameter('locale')}, true), video, "POST", callbackFunction, $(this));
    }

});


/**
 *
 * @returns {boolean}
 */
function isValid(){
    if($.isEmptyObject(videoProduct)){
        toastr.error(Translator.trans('Product not selected!'));
        productSearch.focus();
        return false;
    }

    if (typeSelect.val() === 'upload') {
        if(!title.val()){
            toastr.error(Translator.trans('Video title can not be empty'));
            title.focus();
            return false;
        } else if(!description.val()){
            toastr.error(Translator.trans('Video description can not be empty'));
            description.focus();
            return false;
        } else if(!videoExists()){
            toastr.error(Translator.trans('Video is not added'));
            return false;
        }
    } else {
        if (!playlistId.val()) {
            toastr.error(Translator.trans('Please provide playlist id'));
            return false;
        }
    }

    return true;

}

/**
 *
 * @returns {boolean|*}
 */
function videoExists(){

    return vimeoRegex.test(videoLinkURL.val()) || youtubeRegex.test(videoLinkURL.val()) || (productVideoDropzone != undefined && productVideoDropzone.files.length);
}

/**
 * Collect data and create video object to be sent via adapter
 */
function collectVideoObject(){

    /** Get video title from input */
    var title = $('#productVideoName').val();

    /** Get video description from input */
    var description = $('#videoHTMLDescription').val();


    if (youtubeRegex.test(videoLinkURL.val())) {

        /** Get video youtube url from input */
        var youtubeUrl = videoLinkURL.val();

    } else if (vimeoRegex.test(videoLinkURL.val())) {

        /** Get video vimeo url from input */
        var vimeoUrl = videoLinkURL.val();
    }



    /** Get video enabled status from checkbox */
    var enabled = $('#enabled').prop('checked');


    /** Create video object from data collected */

    var video = {
        id: id,
        title: title,
        description: description,
        enabled: enabled,
        youtube_url: youtubeUrl,
        vimeo_url: vimeoUrl,
        product: videoProduct,
        type: 'upload'
    };

    if( productVideoDropzone != undefined && productVideoDropzone.files.length) {
        video.video_document = videoFile;
    }

    return video;
}

/**
 * On product clicked, add product id to productsInVideo array
 */
$(document).on('click', '.add-product-model', function(){

    /** Get holder element of the clicked product to get information from */
    var parentHolderElement = $(this).closest('.productTagList');

    /** Get product id from data-id attribute */
    var id = parentHolderElement.find('.productName').attr('data-id');

    /** Create product object with Id*/
    var product = {
        id: id
    }
    searchResultDiv.css('visibility', 'hidden');
    /** Set product to main videoProduct */
    videoProduct = product;

    /** Show product preview */
    setProduct(parentHolderElement);

    /** Disable product search when product is selected */
    productSearch.prop( "disabled", true );

    /** Reset search and result fields when product is selected */
    resetSerachAndResult();

});



function setProduct(parentHolderElement){

    /** Collect data from holder element name, price and img for preview */
    var productName = parentHolderElement.find('.productName').html();
    var productPrice = parentHolderElement.find('.info').find('span').html();
    var productImage = parentHolderElement.find('.img-holder').find('img').attr('src');

    /** Set collected data to corresponding elements */
    productPreviewDivImg.attr('src', productImage);
    productPreviewDivProductName.html(productName);
    productPreviewDivProductPrice.html(productPrice);

    /** Show preview div */
    productPreviewDiv.show();
}

/**
 *  Reset video product
 */
$('#remove-product-from-video').on('click', function(){

    /** Set video product to null */
    videoProduct = null;

    /** Hide product preview div */
    productPreviewDiv.hide();

    /** Enable product search input */
    productSearch.prop('disabled', false);

    /** Reset preview product */
    resetPreviewProduct();
});

/**
 *  Reset preview div
 */
function resetPreviewProduct(){

    /** Reset image preview */
    productPreviewDivImg.attr('src', '');
    /** Reset product name preview */
    productPreviewDivProductName.html('');
    /** Reset price preview */
    productPreviewDivProductPrice.html('');
}


/**
 * Reset search field and results
 */
function resetSerachAndResult(){

    /** Empty result container */
    searchResultConteiner.html('');

    /** Remove videw more link */
    viewMoreLink.html('');

    /** Empty product search field */
    productSearch.val('');
}

/** On change check if video can be added */
videoLinkURL.on('change', function(){

    /** Check if dropzone has file uploaded */
    if(productVideoDropzone != undefined && productVideoDropzone.files.length){
        /** Reset input value */
        $(this).val('');
        toastr.error(Translator.trans('Only one video allowed, video file uploaded already'));
    } else {
        /** Save link to variable */
        var videoLinkInput = $(this).val();
        if(videoLinkInput && videoLinkIsValid(videoLinkInput)){
            dropzoneInput.prop("disabled",true);
        }else{
            dropzoneInput.prop('disabled', false);
            videoLinkURL.val('');
        }
    }

});

/**
 *
 * @param videoLinkInput
 * @returns {boolean}
 */
function videoLinkIsValid(videoLinkInput){

    if(!vimeoRegex.test(videoLinkInput) && !youtubeRegex.test(videoLinkInput)){
        //alert('Video link is not valid. Please copy the valid Youtube or Vimeo link');

        toastr.error(Translator.trans('Video link is not valid. Please copy the valid Youtube or Vimeo link'));
        return false;
    }
    else{

        return true;
    }

}
all-product-video.js

The resource all_product_videos_compiled contains file pall-product-video.js, that contains the logic for listing entites. If you are not familiar with jqgrid, please learn more about it.

/**
 * DEFINE COLUMN MODEL FOR JQGRID
 * @type {*[]}
 */
var colModel =[
    { name : 'id', index:'id', align: 'center', searchoptions: {
        sopt: ['eq', 'ne', 'nu', 'nn']
    }},
    { name : 'title', index : 'title', searchoptions: {
        sopt: ['eq', 'ne', 'bw', 'bn', 'ew','en','cn','nc','nu','nn']
    } },
    { name: 'type', index: 'type',
        searchoptions: {
            sopt: ['eq', 'ne', 'bw', 'bn', 'ew', 'en', 'cn', 'nc', 'nu', 'nn']
        },
        formatter: function (cellvalue, options) {
            if (cellvalue) {
                return Translator.trans(cellvalue);
            }
        },
    },
    { name : 'video_file_name', index : 'video_file_name', searchoptions: {
        sopt: ['eq', 'ne', 'bw', 'bn', 'ew','en','cn','nc','nu','nn']
    } },
    { name : 'youtubeUrl', index : 'youtubeUrl', formatter: function(cellvalue, options){
        if(cellvalue != null){
            return "<a href='"+cellvalue+"' target='_blank'>"+Translator.trans('Link')+"</a>"
        }else{
            return "";
        }
    }, searchoptions: {
        sopt: ['eq', 'ne', 'bw', 'bn', 'ew','en','cn','nc','nu','nn']
    } },
    { name : 'vimeoUrl', index : 'vimeoUrl', formatter: function(cellvalue, options){
        if(cellvalue != null){
            return "<a href='"+cellvalue+"' target='_blank'>"+Translator.trans('Link')+"</a>"
        }else{
            return "";
        }

    }, searchoptions: {
        sopt: ['eq', 'ne', 'bw', 'bn', 'ew','en','cn','nc','nu','nn']
    } },
    { name : 'enabled', align: 'center', index : 'enabled', formatter: function(cellvalue, options){
        if(cellvalue){
            return Translator.trans('YES');
        }else{
            return Translator.trans('NO');
        }
    },
        stype: "select",
        searchoptions: {
            sopt: ['eq', 'ne'],value: "-1:" + Translator.trans('CHOOSE') + ";1:" + Translator.trans('YES') + ";0:" + Translator.trans('NO')
        } },
    { name : 'editAction',sortable:false, editable : false, width:40,search:false, formatter: function(cellvalue, options) {
        var id = options.rowId;
        return "<a href="+Routing.generate('product_video_edit', {_locale: getURLParameter('locale'), id: id})+" class='btn btn-xs btn-default btn-quick' data-id=" + id + "><i class='fa fa-pencil'></i></a>";
    }}];

/**
 * DEFINE COLUMN NAMES FOR JQGRID
 * @type {string[]}
 */
var colNames = [Translator.trans('ID'), Translator.trans('Video Title'), Translator.trans('Type'), Translator.trans('Product video'), Translator.trans('Youtube'), Translator.trans('Vimeo'), Translator.trans('Enabled'),''];


/**
 * CUSTOM CELL FORMATTER FOR CATEGORIES
 * @param cellvalue
 * @returns {string}
 */
function descriptionFormatter(cellvalue){
    var newCellValue = $(cellvalue).removeAttr('style').text().replace(/\r?\n|\t|\r/g,'')+'...';
    return newCellValue;
}

/**
 * INITIALIZE JQGRID WITH THE CUSTOM PARAMETERS
 */

setUpjQgrid('jqGrid', 'all_product_videos_json_jqgrid', Translator.trans("All Product Videos"), colNames, colModel,'delete_product_video');
services.yml

This file serves as a list of created services which plugin uses.

services:
      empire_product_video_plugin.product_on_model_adapter:
              class: Plugins\EmpireProductVideoPlugin\Adapter\ProductVideoAdapter
              parent: alligator.product.adapter
              calls:
                      - [setManager, ["@empire_product_video_plugin.product_video_manager"]]
              tags:
                      - { name: request.param_converter, converter: product, priority: 10 }

      empire_product_video_plugin.repository:
                     class: Plugins\EmpireProductVideoPlugin\Business\Database\ProductVideoRepository
                     factory: ["@doctrine.orm.default_entity_manager", getRepository]
                     arguments:
                             - Plugins\EmpireProductVideoPlugin\Model\ProductVideo

      empire_product_video_plugin.product_video_manager:
               class: Plugins\EmpireProductVideoPlugin\Business\Manager\ProductVideoManager
               arguments: ["@empire_product_video_plugin.repository","@empire.product.video.event.container"]

      empire.product.video.event.container:
                     class: Plugins\EmpireProductVideoPlugin\Business\Event\ProductVideoEventContainer
                     arguments: ["@service_container"]

      empire.video_extension:
                  class: Plugins\EmpireProductVideoPlugin\Business\Extension\ProductVideoPluginExtension
                  public: false
                  tags:
                      - { name: twig.extension }
                  arguments: ["@empire_product_video_plugin.product_video_manager"]
Relation between controllers, adapters and converters

First, let's explain the relation between the action in the controller, the adapter, and the converter. During the building of the controller, Symfony will fetch all the values of the controller’s arguments in the attributes of the request. Adapters makes mapping of parameters from URL to the specified converter. To make it work, it is also necessary to declare adapter as a service with tag request.param_converter and extend Alligator\Adapter\BaseAdapter class.

Flow diagram

ProductVideoAdapter.php
<?php
/**
 * This file is part of the FSD e-commerce package.
 *
 * (c) Faust Soft & Design <office@fsd.rs>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace  Plugins\EmpireProductVideoPlugin\Adapter;

use Alligator\Adapter\BaseAdapter;
use Alligator\Adapter\BasicConverter;
use Alligator\Business\Manager\Product\ProductManager;
use Plugins\EmpireProductVideoPlugin\Business\Manager\ProductVideoManager;
use Symfony\Component\HttpFoundation\Request;

/**
 * Class ProductVideoAdapter
 */
class ProductVideoAdapter extends BaseAdapter
{
    /**
     * @var ProductManager
     */
    private $productManager;

    /**ro
     * @param ProductManager $productManager
     */
    public function __construct(ProductManager $productManager)
    {

        $this->productManager = $productManager;
    }

    /**
     * @param string  $param
     * @param Request $request
     *
     * @return BasicConverter
     */
    public function buildConverterInstance($param, Request $request)
    {
        $class = $this->getAdapterUtil();

        $type = $this->getNameSpace().'\\'.ucfirst($param).$class::BASE_CONVERTER_NAME;

        return new $type($this->productManager, $request, $param);
    }

    /**
     * @param ProductVideoManager $manager
     */
    public function setManager(ProductVideoManager $manager)
    {
        $this->productManager = $manager;
    }
}

Now, how to make connection between controllers and converters? For demonstrative purposes, we will use productVideoAddJSONAction from the ProductVideoController:

ProductVideoController.php
<?php
/**
 * This file is part of the FSD e-commerce package.
 *
 * (c) Faust Soft & Design <office@fsd.rs>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace Plugins\EmpireProductVideoPlugin\Routing;

use Doctrine\Common\Collections\ArrayCollection;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

/**
 * Class ProductVideoController
 */
class ProductVideoController extends Controller
{

    /**
     * @Route("/{_locale}/backend/product-video/new", name="product_video_new", defaults={"_locale": "en"},
     * options={"expose" = true})
     *
     * @Template("@productVideoPluginViews/product-video-add.html.twig")
     *
     * @return array
     */
    public function productVideoNewAction()
    {

        return array();
    }


    /**
     * @Route("/{_locale}/private/json/product-video-search", name="json_product_video_search", defaults={"_locale": "en"},
     * options={"expose" = true})
     *
     * @param ArrayCollection $productVideoSearchJSON
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function productVideoSearchJSONAction(ArrayCollection $productVideoSearchJSON)
    {

        $productsHtml = $this->renderView('@productVideoPluginViews/products-search-result.html.twig', array('products' => $productVideoSearchJSON[0]));

        return new JsonResponse(array('html' => $productsHtml, 'hasMoreResults' => $productVideoSearchJSON[1]));
    }

    /**
     * @Route("/{_locale}/private/json/product-video-add", name="json_product_video_add", defaults={"_locale": "en"},
     * options={"expose" = true})
     *
     * @param ArrayCollection $productVideoSaveJSON
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function productVideoAddJSONAction(ArrayCollection $productVideoSaveJSON)
    {

        return new JsonResponse($productVideoSaveJSON->toArray());
    }

    /**
     * @Route("/{_locale}/private/json/product-video-edit", name="json_product_video_edit", defaults={"_locale": "en"},
     * options={"expose" = true})
     * @param ArrayCollection $productVideoEditJSON
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function productVideoEditJSONAction(ArrayCollection $productVideoEditJSON)
    {
        return new JsonResponse($productVideoEditJSON->toArray());
    }


    /**
     * @Route("/{_locale}/backend/product-video/all", name="product_videos_all", defaults={"_locale": "en"}, options={"expose" = true})
     *
     * @Template("@productVideoPluginViews/product-videos-all.html.twig")
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function frontendProductOnModelAction()
    {

        return array();
    }


    /**
     * @Route("/{_locale}/private/json/product-videos-jqgrid", name="all_product_videos_json_jqgrid", defaults={"_locale": "en"}, options={"expose" = true})
     *
     * @param ArrayCollection $productVideoListJSON
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function getAllProductsJQGridAction(ArrayCollection $productVideoListJSON)
    {
        return new JsonResponse($productVideoListJSON->toArray());
    }

    /**
     * @Route("/{_locale}/backend/product-video/edit/{id}", name="product_video_edit", defaults={"_locale": "en"},
     * options={"expose" = true})
     *
     * @Template("@productVideoPluginViews/product-video-edit.html.twig")
     *
     * @param ArrayCollection $productVideoEdit
     *
     * @return array
     */
    public function productVideoEditAction(ArrayCollection $productVideoEdit)
    {
        return array('video' => $productVideoEdit[0]);
    }


    /**
     * @Route("/{_locale}/private/json/delete-product-video", name="delete_product_video", defaults={"_locale": "en"}, options={"expose" = true})
     *
     * @param ArrayCollection $productVideoDeleteJSON
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function deleteProductAction(ArrayCollection $productVideoDeleteJSON)
    {
        /** return JSON Response */

        return new JsonResponse($productVideoDeleteJSON->toArray());
    }


    /**
     * @Route("/{_locale}/json/product-video/section", name="product_video_section", defaults={"_locale": "en"}, options={"expose" = true})
     *
     * @param Request $request
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function getProductDescriptionTabSectionAction(Request $request)
    {
        $theme = $request->getSession()->get('_theme');
        $productId = json_decode($request->getContent())->product_id;

        try {
            $html = $this->get('templating')->render($theme.'/Plugins/EmpireProductVideoPlugin/views/video-tab-section-template.html.twig', array(
                'productId' => $productId,
            ));
        } catch (\Exception $e) {
            $html = $this->get('templating')->render('@productVideoPluginViews/video-tab-section-template.html.twig', array(
                'productId' => $productId,
            ));
        }

        return new JsonResponse(['html' => $html]);
    }
}

As you can see in the controller, the first parameter of the productVideoAddJSONAction function is productVideoSaveJSON. Now, we need to 'link' function's first parameter to specific converter. The adapter (ProductVideoAdapter) is responsible for creating the specific converter, in our case ProductVideoSaveJSONConverter. As we said before, each controller’s arguments must be specified as constant in Util class. This class must be placed in the same directory as the adapter.

ProductVideoAdapterUtil.php
<?php
/**
 * This file is part of the FSD e-commerce package.
 *
 * (c) Faust Soft & Design <office@fsd.rs>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace Plugins\EmpireProductVideoPlugin\Adapter;

use Alligator\Adapter\BaseAdapterUtil;

/**
 * Class ProductVideoAdapterUtil
 */
abstract class ProductVideoAdapterUtil extends BaseAdapterUtil
{

    /** each adapter class MUST end with this */
    const BASE_CONVERTER_NAME = 'Converter';

    const __PRODUCT_VIDEO_CLASS__ = 'Plugins\EmpireProductVideoPlugin\Model\ProductVideo';

    const BACKEND_PRODUCT_VIDEO_SEARCH = 'productVideoSearchJSON';
    const BACKEND_PRODUCT_VIDEO_SAVE = 'productVideoSaveJSON';
    const BACKEND_PRODUCT_VIDEO_LIST_JSON = 'productVideoListJSON';
    const BACKEND_PRODUCT_VIDEO_EDIT_JSON = 'productVideoEditJSON';
    const BACKEND_PRODUCT_VIDEO_EDIT = 'productVideoEdit';
    const BACKEND_PRODUCT_VIDEO_DELETE = 'productVideoDeleteJSON';

}
ProductVideoSaveJSONConverter.php

All magic occurs in the convert method.

<?php
/**
 * This file is part of the FSD e-commerce package.
 *
 * (c) Faust Soft & Design <office@fsd.rs>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace Plugins\EmpireProductVideoPlugin\Adapter;

use Alligator\Adapter\BasicConverter;

use Doctrine\Common\Collections\ArrayCollection;
use Plugins\EmpireProductVideoPlugin\Business\Manager\ProductVideoManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * Class ProductVideoSaveJSONConverter
 */
class ProductVideoSaveJSONConverter extends BasicConverter
{
    /**
     * @var ProductVideoManager
     */
    protected $manager;

    /**
     * @return mixed
     *
     * @throws \Exception
     */
    public function convert()
    {

        $video = ProductVideoDeserializer::deserialize($this->request->getContent());


        $video = $this->manager->saveProductVideo($video);

        if ($video->getId()) {
            $message = 'Video successfully saved';

            $this->request->attributes->set($this->param, new ArrayCollection(array('code' => Response::HTTP_OK, 'status' => 'Success', 'message' => $message, 'videoId' => $video->getId())));
        } else {
            $message = 'Video saving failed';

            $this->request->attributes->set($this->param, new ArrayCollection(array('code' => Response::HTTP_FAILED_DEPENDENCY, 'status' => 'Failed', 'message' => $message)));

        }
    }
}

As you can see, the converter is connected to the manager, which contain the business logic of plugin.

ProductVideoManager.php
<?php

/**
 * This file is part of the FSD e-commerce package.
 *
 * (c) Faust Soft & Design <office@fsd.rs>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace Plugins\EmpireProductVideoPlugin\Business\Manager;

use Alligator\Adapter\JQGridInterface;
use Alligator\Model\Core\Resource\Model\BasicEntityManagerInterface;
use Plugins\EmpireProductVideoPlugin\Business\Database\ProductVideoRepository;
use Plugins\EmpireProductVideoPlugin\Business\Event\ProductVideoEventContainer;
use Plugins\EmpireProductVideoPlugin\Model\ProductVideo;

/**
 * Class ProductVideoManager
 */
class ProductVideoManager implements JQGridInterface, BasicEntityManagerInterface
{

    const UPLOAD_TYPE   = 'upload';
    const PLAYLIST_TYPE = 'playlist';

    /**
     * @var ProductVideoRepository
     */
    protected $repository;

    /**
    /**
     * @var ProductVideoEventContainer
     */
    protected $eventContainer;

    /**
     * @param ProductVideoRepository     $repository
     * @param ProductVideoEventContainer $eventContainer
     */
    public function __construct(ProductVideoRepository $repository, ProductVideoEventContainer $eventContainer)
    {
        $this->repository = $repository;
        $this->eventContainer = $eventContainer;
    }

    /**
     * @param ProductVideo $video
     *
     * @return \Alligator\Model\Core\Resource\Model\PrimaryKeyInterface
     *
     * @throws \Exception
     */
    public function saveProductVideo(ProductVideo $video)
    {

        $product =  $this->getProductForVideo($video);

        $video->setProduct($product);

        if (!is_null($video->getVideoDocument())) {
            $video = $this->setProductVideoFile($video);
        }

        try {
            return $this->repository->save($video);
        } catch (\Exception $ex) {
            if ($video->getVideoDocument()) {
                $video->getVideoDocument()->deleteFile();
            }

            return new ProductVideo();
        }
    }

    /**
     * @param ProductVideo $video
     *
     * @return \Alligator\Model\Core\Resource\Model\PrimaryKeyInterface
     *
     * @throws \Exception
     */
    public function editProductVideo(ProductVideo $video)
    {

        $product =  $this->getProductForVideo($video);
        /** @var ProductVideo $dbProductVideo */
        $dbProductVideo = $this->repository->findOneById($video->getId());
        if ($dbProductVideo->getProduct()->getId() !== $product->getId()) {
            $dbProductVideo->setProduct($product);
        }

        if ($video->getVideoDocument() !== null) {
            $this->setProductVideoFileForEdit($video, $dbProductVideo);
        } else {
            $dbProductVideo->setVideoDocument(null);
        }
        $dbProductVideo->setTitle($video->getTitle());
        $dbProductVideo->setDescription($video->getDescription());
        $dbProductVideo->setEnabled($video->isEnabled());
        $dbProductVideo->setYoutubeUrl($video->getYoutubeUrl());
        $dbProductVideo->setVimeoUrl($video->getVimeoUrl());
        $dbProductVideo->setType($video->getType());
        $dbProductVideo->setPlaylistId($video->getPlaylistId());

        try {
            return $this->repository->edit($dbProductVideo);
        } catch (\Exception $ex) {
            if ($video->getVideoDocument() && !$video->getVideoDocument()->getId()) {
                $video->getVideoDocument()->deleteFile();
            }

            return new ProductVideo();
        }
    }
    /**
     * @param ProductVideo $video
     *
     * @return mixed
     */
    public function getProductForVideo(ProductVideo $video)
    {
        return $this->eventContainer->getProductForVideo($video);
    }

    /**
     * @param string $nameString
     * @param int    $page
     * @param int    $offset
     *
     * @return mixed
     */
    public function getProductsByNameJSON($nameString, $page, $offset)
    {
        return $this->eventContainer->getProductsByNameJSON($nameString, $page, $offset);
    }

    /**
     * @param mixed $searchParams
     * @param array $sortParams
     * @param array $additionalParams
     *
     * @return mixed
     */
    public function searchForJQGRID($searchParams, $sortParams = array(), $additionalParams = array())
    {
        return $this->repository->searchProductVideosForJQGRID($searchParams, $this->hasRoleAdmin(), $this->getCurrentLocale(), false, $sortParams);
    }

    /**
     * @param int    $page
     * @param int    $offset
     * @param string $sortParams
     * @param array  $additionalParams
     *
     * @return mixed
     */
    public function findAllForJQGRID($page, $offset, $sortParams, $additionalParams = array())
    {
        return $this->repository->getAllVideosJQGRID($page, $offset, $this->hasRoleAdmin(), $this->getCurrentLocale(), $sortParams, $additionalParams);
    }

    /**
     * @param null  $searchParams
     * @param null  $sortParams
     * @param array $additionalParams
     *
     * @return mixed
     */
    public function getCountForJQGRID($searchParams = null, $sortParams = null, $additionalParams = array())
    {
        return $this->repository->searchProductVideosForJQGRID($searchParams, $this->hasRoleAdmin(), $this->getCurrentLocale(), true, $sortParams, $additionalParams);
    }


    /**
     * @param  array $productVideos
     *
     * @return mixed
     */
    public function deleteProductVideos($productVideos)
    {
        $productsDb = $this->repository->getVideosArray($productVideos);

        return $this->repository->deleteVideos($productsDb);
    }

    /**
     * @param  array $productVideosArray
     *
     * @return mixed
     */
    public function getVideosArray($productVideosArray)
    {
        return $this->repository->getVideosArray($productVideosArray);
    }

    /**
     * @param int $id
     *
     * @return mixed
     */
    public function getVideoByProduct($id)
    {

        return $this->repository->getVideoByProduct($id);
    }

    /**
     * @param int $id
     *
     * @return mixed
     */
    public function getVideoByPk($id)
    {
        return $this->repository->getVideoByPk($id);
    }

    /**
     * @return mixed
     */
    public function hasRoleAdmin()
    {
        return $this->eventContainer->hasRoleAdmin();
    }

    /**
     * @return string
     */
    public function getCurrentLocale()
    {
        return $this->eventContainer->getDefaultLocale();
    }

    /**
     * @param ProductVideo $productVideo
     *
     * @return ProductVideo
     */
    protected function setProductVideoFile(ProductVideo $productVideo)
    {
        $video = $productVideo->getVideoDocument();
        if ($video->getId()) {
        } elseif ($video->saveToFile($video->getBase64Content())) {
            $productVideo->setVideoDocument($video);
        }

        return $productVideo;
    }

    /**
     * @param ProductVideo $productVideo
     * @param ProductVideo $dbProductVideo
     *
     * @return ProductVideo
     */
    protected function setProductVideoFileForEdit(ProductVideo $productVideo, ProductVideo $dbProductVideo)
    {
        $video = $productVideo->getVideoDocument();
        $dbVideo = $dbProductVideo->getVideoDocument();
        if ($video->getId() !== $dbVideo->getId()) {
            if ($video->saveToFile($video->getBase64Content())) {
                $dbProductVideo->setVideoDocument($video);
            }
        }


        return $dbProductVideo;
    }
}

Each manager should have injected at least one service, and this should be an entity repository, which contains functions that are used for database interaction.

ProductVideoRepository.php
<?php

/**
 * This file is part of the FSD e-commerce package.
 *
 * (c) Faust Soft & Design <office@fsd.rs>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace Plugins\EmpireProductVideoPlugin\Business\Database;

use Alligator\Model\Core\Resource\Model\PrimaryKeyInterface;
use Doctrine\ORM\EntityRepository;
use Exception;
use Plugins\EmpireProductVideoPlugin\Model\ProductVideo;
use Plugins\EmpireProductVideoPlugin\Model\Video;

/**
 * Class ProductVideoRepository
 */
class ProductVideoRepository extends EntityRepository
{
    const ALIAS = 'productVideo';
    const JOIN_WITH_PRODUCT_ALIAS  = 'product';
    const JOIN_WITH_VIDEO_DOCUMENT = 'videoDocument';


    /**
     * @param int $id
     *
     * @return mixed
     */
    public function getVideoByProduct($id)
    {
        $qb = $this->createQueryBuilder(self::ALIAS);
        $qb->select(
            self::ALIAS.'.id as id',
            self::ALIAS.'.title as title',
            self::ALIAS.'.description as description',
            self::ALIAS.'.enabled as enabled',
            self::ALIAS.'.youtubeUrl as youtube',
            self::ALIAS.'.vimeoUrl as vimeo',
            self::JOIN_WITH_VIDEO_DOCUMENT.'.name as videoDocumentName',
            self::ALIAS.'.type',
            self::ALIAS.'.playlistId'
        );

        $qb->leftJoin(self::ALIAS.'.product', self::JOIN_WITH_PRODUCT_ALIAS)
            ->leftJoin(self::ALIAS.'.videoDocument', self::JOIN_WITH_VIDEO_DOCUMENT);
        $qb->where(self::JOIN_WITH_PRODUCT_ALIAS.'.id = '.$id);
        $qb->andWhere(self::ALIAS.'.enabled = 1');

        $videos = $qb->getQuery()->getResult();

        foreach (($array = json_decode(json_encode($videos))) as $key => $video) {
            $videoFile = new Video();
            $videoFile->setName($video->videoDocumentName);
            $video->videoFilePath = $videoFile->getWebPath();
            $video->absoluteVideoFilePath = $videoFile->getAbsolutePathWithVersion();
            $array[$key] = $video;


        }

        return $array;
    }


    /**
     * @param mixed  $searchParams
     * @param bool   $isAdmin
     * @param String $locale
     * @param bool   $isCountSearch
     * @param array  $sortParams
     *
     * @return array
     */
    public function searchProductVideosForJQGRID($searchParams, $isAdmin, $locale, $isCountSearch = false, $sortParams = array())
    {

        $oQ0 = $this->createQueryBuilder(self::ALIAS);
        $oQ0->leftJoin(self::ALIAS.'.videoDocument', self::JOIN_WITH_VIDEO_DOCUMENT);

        if (!$isCountSearch) {
            $oQ0->select(self::ALIAS.'.id', self::ALIAS.'.title as title', self::ALIAS.'.type', 'SUBSTRING('.self::ALIAS.'.description, 1,34) as description', self::ALIAS.'.enabled as enabled', self::ALIAS.'.youtubeUrl as youtubeUrl', self::ALIAS.'.vimeoUrl as vimeoUrl', self::JOIN_WITH_VIDEO_DOCUMENT.'.name as video_file_name');
        }
        $firstResult = 0;
        $offset = 0;

        if ($searchParams) {
            if ($searchParams[0]['toolbar_search']) {
                $page = $searchParams[0]['page'];
                $offset = $searchParams[0]['rows'];
                $firstResult = 0;
                if ((1 !== $page)) {
                    $firstResult = ($page - 1) * $offset;
                    $offset = $page * $offset;
                }
                array_shift($searchParams);
                foreach ($searchParams[0] as $key => $param) {
                    if ('enabled' === $key) {
                        if (-1 === $param) {
                            $oQ0->andWhere(self::ALIAS.'.'.$key.'= '.$param);
                        } else {
                            $oQ0->andWhere(self::ALIAS.'.'.$key.'= 0 OR '.self::ALIAS.'.'.$key.'= 1');
                        }
                    } elseif ('videoDocument.name' === $key) {
                        $oQ0->andWhere($oQ0->expr()->like(self::JOIN_WITH_VIDEO_DOCUMENT.'.name', $oQ0->expr()->literal('%'.$param.'%')));
                    } else {
                        $oQ0->andWhere($oQ0->expr()->like(self::ALIAS.'.'.$key, $oQ0->expr()->literal('%'.$param.'%')));
                    }

                }

            } else {
                $searchParams = $searchParams[1];
                $searchField = $searchParams['searchField'];
                $searchString = $searchParams['searchString'];
                $searchOperator = $searchParams['searchOper'];
                $page = $searchParams['page'];
                $offset = $searchParams['rows'];
                $firstResult = 0;
                if (1 !== $page) {
                    $firstResult = ($page - 1) * $offset;
                    $offset = $page * $offset;
                }
                //text fields
                if (!is_numeric($searchString)) {
                    switch ($searchOperator) {
                        case 'eq':
                            $oQ0->andWhere(
                                $oQ0->expr()->eq(self::ALIAS.'.'.$searchField, $oQ0->expr()->literal($searchString))
                            );
                            break;
                        case 'ne':
                            $oQ0->andWhere(
                                $oQ0->expr()->not(
                                    $oQ0->expr()->eq(self::ALIAS.'.'.$searchField, $oQ0->expr()->literal($searchString))
                                )
                            );
                            break;
                        case 'bw':
                            $oQ0->andWhere(
                                $oQ0->expr()->like(self::ALIAS.'.'.$searchField, $oQ0->expr()->literal($searchString.'%'))
                            );
                            break;
                        case 'bn':
                            $oQ0->andWhere(
                                $oQ0->expr()->not(
                                    $oQ0->expr()->like(
                                        self::ALIAS.'.'.$searchField,
                                        $oQ0->expr()->literal($searchString.'%')
                                    )
                                )
                            );
                            break;
                        case 'ew':
                            $oQ0->andWhere(
                                $oQ0->expr()->like(self::ALIAS.'.'.$searchField, $oQ0->expr()->literal('%'.$searchString))
                            );
                            break;
                        case 'en':
                            $oQ0->andWhere(
                                $oQ0->expr()->not(
                                    $oQ0->expr()->like(
                                        self::ALIAS.'.'.$searchField,
                                        $oQ0->expr()->literal($searchString.'%')
                                    )
                                )
                            );
                            break;
                        case 'cn':
                            $oQ0->andWhere(
                                $oQ0->expr()->like(
                                    self::ALIAS.'.'.$searchField,
                                    $oQ0->expr()->literal('%'.$searchString.'%')
                                )
                            );
                            break;
                        case 'nc':
                            $oQ0->andWhere(
                                $oQ0->expr()->not(
                                    $oQ0->expr()->like(
                                        self::ALIAS.'.'.$searchField,
                                        $oQ0->expr()->literal('%'.$searchString.'%')
                                    )
                                )
                            );
                            break;
                        case 'nu':
                            $oQ0->andWhere($oQ0->expr()->isNull(self::ALIAS.'.'.$searchField));
                            break;
                        case 'nn':
                            $oQ0->andWhere($oQ0->expr()->not($oQ0->expr()->isNull(self::ALIAS.'.'.$searchField)));
                            break;
                    }
                }
            }
        }

        if ($isCountSearch) {
            $oQ0->select('COUNT('.self::ALIAS.')');

        } else {
            $oQ0->setFirstResult($firstResult)->setMaxResults($offset);
        }

        if ($sortParams) {
            $oQ0->orderBy($sortParams[0] === 'videoDocument.name'?self::JOIN_WITH_VIDEO_DOCUMENT.'.name':self::ALIAS.'.'.$sortParams[0], $sortParams[1]);
        }

        return $oQ0->getQuery()->getResult();
    }

    /**
     * @param mixed $page
     * @param mixed $offset
     * @param mixed $isAdmin
     * @param mixed $locale
     * @param array $sortParams
     * @param array $additionalParams
     *
     * @return array
     */
    public function getAllVideosJQGRID($page, $offset, $isAdmin, $locale, $sortParams = array(), $additionalParams = array())
    {

        $firstResult = 0;
        if (1 !== $page) {
            $firstResult = ($page-1)*$offset;
        }
        $qb = $this->createQueryBuilder($this->getAlias());
        $qb->select(self::ALIAS.'.id', self::ALIAS.'.title', self::ALIAS.'.type', 'SUBSTRING('.self::ALIAS.'.description, 1, 34) as description', self::ALIAS.'.enabled', self::ALIAS.'.youtubeUrl', self::ALIAS.'.vimeoUrl', self::JOIN_WITH_VIDEO_DOCUMENT.'.name as video_file_name');
        $qb->leftJoin(self::ALIAS.'.videoDocument', self::JOIN_WITH_VIDEO_DOCUMENT);

        $qb->setFirstResult($firstResult)->setMaxResults($offset)->orderBy($sortParams[0] === 'videoDocument.name'?self::JOIN_WITH_VIDEO_DOCUMENT.'.name':$this->getAlias().'.'.$sortParams[0], $sortParams[1]);

        return $qb->getQuery()->getResult();
    }


    /**
     * @param int $id
     *
     * @return mixed
     */
    public function getVideoByPk($id)
    {
        $qb = $this->createQueryBuilder(self::ALIAS);
        $qb->where(self::ALIAS.'.id = '.$id);

        return $qb->getQuery()->getResult();
    }

    /**
     * @param ProductVideo $video
     *
     * @return object|ProductVideo
     *
     * @throws \Exception
     */
    public function editVideo(ProductVideo $video)
    {
        $this->_em->beginTransaction();
        try {
            $video = $this->_em->merge($video);
            $this->_em->flush();

        } catch (\Exception $e) {
            $this->_em->rollback();
            throw $e;
        }
        $this->_em->commit();

        return $video;
    }

    /**
     * @param mixed $array
     *
     * @return array
     */
    public function getVideosArray($array)
    {
        $qb = $this->createQueryBuilder($this->getAlias());
        $index = 1;
        foreach ($array as $item) {
            $qb->orWhere($this->getAlias().'.id = ?'.$index);
            $qb->setParameter($index, $item);
            $index++;
        }

        return $qb->getQuery()->getResult();
    }

    /**
     * @param  array $productsVideos
     *
     * @return mixed
     */
    public function deleteVideos($productsVideos)
    {
        $manager = $this->getEntityManager();
        try {
            foreach ($productsVideos as $entity) {
                $manager->remove($entity);
            }
            $manager->flush();
        } catch (Exception $e) {
            return new ProductVideo();
        }

        return $productsVideos;
    }

    /**
     * @param PrimaryKeyInterface $entity
     *
     * @return PrimaryKeyInterface
     *
     * @throws \Exception
     */
    public function save(PrimaryKeyInterface $entity)
    {
        $this->_em->persist($entity);
        $this->_em->flush();

        return $entity;
    }

    /**
     * @param mixed $entity
     *
     * @throws \Exception
     */
    public function remove($entity)
    {

        $this->_em->remove($entity);
        $this->_em->flush();
    }



    /**
     * @param PrimaryKeyInterface $entity
     *
     * @return PrimaryKeyInterface
     *
     * @throws \Exception
     */
    public function edit(PrimaryKeyInterface $entity)
    {
        $this->_em->merge($entity);
        $this->_em->flush();



        return $entity;
    }

    /**
     * @return string
     */
    protected function getAlias()
    {
        return 'productVideo';
    }

}
model.yml

This file defines where to put doctrine entities. By the convention, it is a highly recommendation to put them in the Model directory.

doctrine:
        mappings:
          product_video_plugin:
            type: annotation
            dir: "%kernel.root_dir%/../src/Plugins/EmpireProductVideoPlugin/Model"
            prefix: Plugins\EmpireProductVideoPlugin\Model
            is_bundle: false
routing.yml

There is also one addition predefined file (routing.yml) in config directory, in which the controllers are defined. Your controllers are part of the Application Layer.

alligator_product_video_plugin_controller:
    resource: Plugins\EmpireProductVideoPlugin\Routing\ProductVideoController
    type:     annotation
    prefix:   /

Generate plugin using cli command

Creating plugins can be quite repetitive - if there was just a way to generate the base structure automatically. Well - there is a tool for that...

After installing the application you can just run F-Webshop commands in you command line. With the new command empire-cli:generate:empire_plugin the code generator is triggered. See full command API documentation

php bin/console empire-cli:generate:empire_plugin SimplePLugin

Ovveriding templates

When using the @Template annotation, the controller should return an array of parameters to pass to the view instead of a Response object. kernel.view event is dispatched after the controller has been executed but only if the controller does not return a Response object. It's useful to transform the returned value (e.g. a string with some HTML contents) into the Response. Ok, but what’s the use-case for this? In a true MVC framework, the controller is supposed to just return some data, not a response. The result would be the same, but we’d be splitting things into two pieces. The fetching and preparation of the data would happen in the controller. The creation of a representation of that data would happen in the listener (RouteThemeListener).
Every plugin's template can be overridden in Theme Folder. For example, if we want to ovveride video-tab-section.html.twig templates, we must crete this file in:

src/Themes/[ThemeName]/Plugins/EmpireProductVideoPlugin/views/video-tab-section.html.twig